---
/**
* Collection Details Page
*
* Displays comprehensive collection information including:
* - Collection metadata and settings
* - Inline edit functionality
* - Resources (planned and managed)
* - Team members and permissions
* - Workflow configuration
*/
import Layout from '../../layouts/Layout.astro';
const { id } = Astro.params;
// Get auth context from middleware
const authContext = Astro.locals.auth;
// Redirect if not authenticated
if (!authContext.isAuthenticated) {
return Astro.redirect('/auth/login');
}
const authToken = Astro.cookies.get('auth_token')?.value;
// Fetch collection details
let collection = null;
let members = [];
let resources = [];
let error = null;
try {
// Always use container service name for internal communication
const baseUrl = 'http://vultr-backend:8000';
const headers = {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
};
// Fetch collection data
const collectionRes = await fetch(`${baseUrl}/api/collections/${id}`, {
headers
});
if (!collectionRes.ok) {
if (collectionRes.status === 404) {
error = 'Collection not found';
} else if (collectionRes.status === 403) {
error = 'Access denied - you do not have permission to view this collection';
} else {
error = `Failed to load collection: ${collectionRes.statusText}`;
}
} else {
collection = await collectionRes.json();
// Fetch members and resources in parallel
const [membersRes, resourcesRes] = await Promise.all([
fetch(`${baseUrl}/api/collections/${id}/members`, { headers }),
fetch(`${baseUrl}/api/resources/managed?collection_id=${id}`, { headers })
]);
if (membersRes.ok) {
const data = await membersRes.json();
members = data.members || [];
}
if (resourcesRes.ok) {
const data = await resourcesRes.json();
resources = data.items || [];
}
}
} catch (err) {
console.error('Error fetching collection:', err);
error = 'Failed to load collection data';
}
// Redirect to collections page if error
if (error || !collection) {
return Astro.redirect('/collections?error=' + encodeURIComponent(error || 'Unknown error'));
}
// Check if user has edit permissions (owner or manager role)
const currentUserEmail = authContext.user?.email;
const isOwner = currentUserEmail === collection.owner_email;
const userMember = members.find(m => m.email === currentUserEmail);
const canEdit = isOwner || (userMember && ['owner', 'manager', 'editor'].includes(userMember.role));
const canManage = isOwner || (userMember && ['owner', 'manager'].includes(userMember.role));
// Environment badge colors
const envColors = {
development: 'bg-blue-100 text-blue-800',
testing: 'bg-yellow-100 text-yellow-800',
staging: 'bg-purple-100 text-purple-800',
production: 'bg-green-100 text-green-800'
};
// Status badge colors
const statusColors = {
draft: 'bg-gray-100 text-gray-800',
pending_approval: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
suspended: 'bg-red-100 text-red-800',
archived: 'bg-gray-100 text-gray-600'
};
---
<Layout title={`${collection.name} - Collection Details`}>
<div class="min-h-screen bg-gray-50">
<!-- Header Section -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900" id="collection-name-display">
{collection.name}
</h1>
<p class="text-sm text-gray-500 mt-1">
<span class={`inline-block px-2 py-1 rounded text-xs font-medium mr-2 ${envColors[collection.environment]}`}>
{collection.environment}
</span>
<span class={`inline-block px-2 py-1 rounded text-xs font-medium mr-2 ${statusColors[collection.status]}`}>
{collection.status}
</span>
<span>{members.length} {members.length === 1 ? 'member' : 'members'}</span>
</p>
<p class="text-gray-600 mt-2" id="collection-description-display">
{collection.description || 'No description provided'}
</p>
</div>
<!-- Actions -->
<div class="flex items-center space-x-3">
{canEdit && (
<button
id="edit-collection-btn"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg class="h-5 w-5 mr-2 text-gray-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit Collection
</button>
)}
<a
href="/collections"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Back to Collections
</a>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content Column -->
<div class="lg:col-span-2 space-y-10">
<!-- Configuration Section -->
<section class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Configuration</h2>
</div>
<div class="p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Vultr Service User</dt>
<dd class="mt-1 text-sm text-gray-900">{collection.vultr_service_user || 'Not configured'}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Monthly Budget</dt>
<dd class="mt-1 text-sm text-gray-900">{collection.cost_budget_monthly ? `$${collection.cost_budget_monthly}` : 'Not set'}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">Allowed Regions</dt>
<dd class="mt-1 text-sm text-gray-900">
{collection.allowed_regions && collection.allowed_regions.length > 0 ? (
<div class="flex flex-wrap gap-2">
{collection.allowed_regions.map((region) => (
<span class="inline-block px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
{region}
</span>
))}
</div>
) : (
<span class="text-gray-500">All regions allowed</span>
)}
</dd>
</div>
</dl>
</div>
</section>
<!-- Team Members Section -->
<section class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900">Team Members ({members.length})</h2>
{canManage && (
<button class="text-sm text-indigo-600 hover:text-indigo-500">
Invite Member
</button>
)}
</div>
</div>
<div class="p-6">
{members.length === 0 ? (
<p class="text-gray-500 text-center py-4">No team members</p>
) : (
<div class="space-y-4">
{members.map((member) => (
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
<span class="text-sm font-medium text-indigo-600">
{member.full_name?.charAt(0).toUpperCase() || member.email.charAt(0).toUpperCase()}
</span>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">{member.full_name || member.email}</p>
<p class="text-sm text-gray-500">{member.email}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{member.role}
</span>
{member.is_owner && (
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
Owner
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</section>
<!-- Managed Resources Section -->
<section class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900">Managed Resources ({resources.length})</h2>
{canEdit && (
<a
href={`/import-resources?collection_id=${collection.id}`}
class="text-sm text-indigo-600 hover:text-indigo-500 flex items-center">
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Import Resources
</a>
)}
</div>
</div>
<div class="p-6">
{resources.length === 0 ? (
<div class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No resources</h3>
<p class="mt-1 text-sm text-gray-500">Get started by importing resources from Vultr.</p>
{canEdit && (
<div class="mt-4">
<a
href={`/import-resources?collection_id=${collection.id}`}
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Import Resources
</a>
</div>
)}
</div>
) : (
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th scope="col" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th scope="col" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Region</th>
<th scope="col" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Cost</th>
<th scope="col" class="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{resources.map((resource) => {
const typeColors = {
instance: 'bg-blue-100 text-blue-800',
load_balancer: 'bg-purple-100 text-purple-800',
database: 'bg-green-100 text-green-800',
kubernetes: 'bg-orange-100 text-orange-800',
block_storage: 'bg-gray-100 text-gray-800',
dns: 'bg-teal-100 text-teal-800'
};
const statusColors = {
active: 'bg-green-100 text-green-800',
running: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
stopped: 'bg-gray-100 text-gray-800',
error: 'bg-red-100 text-red-800'
};
const resourceType = resource.resource_type || 'unknown';
const resourceStatus = resource.status || 'unknown';
const metadata = resource.resource_metadata || {};
return (
<tr class="hover:bg-gray-50">
<td class="px-3 py-4 whitespace-nowrap">
<div class="flex items-center">
<div>
<div class="text-sm font-medium text-gray-900">{resource.resource_name}</div>
<div class="text-xs text-gray-500 font-mono">{resource.vultr_resource_id}</div>
</div>
</div>
</td>
<td class="px-3 py-4 whitespace-nowrap">
<span class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${typeColors[resourceType] || 'bg-gray-100 text-gray-800'}`}>
{resourceType.replace('_', ' ')}
</span>
</td>
<td class="px-3 py-4 whitespace-nowrap">
<span class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${statusColors[resourceStatus] || 'bg-gray-100 text-gray-800'}`}>
{resourceStatus}
</span>
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
{metadata.region || '-'}
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
{resource.monthly_cost && resource.monthly_cost !== 'None' && resource.monthly_cost !== 'null' ? `$${resource.monthly_cost}/mo` : '-'}
</td>
<td class="px-3 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="relative inline-block text-left">
<button
type="button"
class="resource-actions-btn inline-flex items-center px-2 py-1 text-gray-400 hover:text-gray-600 focus:outline-none"
data-resource-id={resource.id}
data-resource-name={resource.resource_name}
data-resource-type={resourceType}
data-vultr-id={resource.vultr_resource_id}
data-can-refresh={resource.can_refresh ? 'true' : 'false'}
onclick={`toggleResourceMenu('${resource.id}')`}
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<div
id={`resource-menu-${resource.id}`}
class="resource-dropdown hidden absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
role="menu"
>
<div class="py-1" role="none">
<button
type="button"
class="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onclick={`viewResourceDetails('${resource.id}')`}
role="menuitem"
>
<svg class="mr-3 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.64 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.64 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
View Details
</button>
<button
type="button"
class="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onclick={`refreshResource('${resource.id}')`}
role="menuitem"
>
<svg class="mr-3 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Refresh from Vultr
</button>
{canEdit && (
<>
<div class="border-t border-gray-100 my-1"></div>
<button
type="button"
class="flex w-full items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50"
onclick={`removeResource('${resource.id}', '${resource.resource_name}')`}
role="menuitem"
>
<svg class="mr-3 h-4 w-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
Remove from Collection
</button>
</>
)}
</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</section>
</div>
<!-- Sidebar -->
<div class="space-y-8">
<!-- Collection Statistics -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-base font-medium text-gray-900 mb-4">Collection Statistics</h3>
<dl class="space-y-3">
<div>
<dt class="text-sm text-gray-500">Resources</dt>
<dd class="text-lg font-semibold text-gray-900">{resources.length}</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Team Members</dt>
<dd class="text-lg font-semibold text-gray-900">{members.length}</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Your Role</dt>
<dd class="text-lg font-semibold text-gray-900">{userMember?.role || 'viewer'}</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Approval Required</dt>
<dd class="text-lg font-semibold text-gray-900">{collection.approval_required ? 'Yes' : 'No'}</dd>
</div>
</dl>
</div>
<!-- Collection Information -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-base font-medium text-gray-900 mb-4">Collection Information</h3>
<dl class="space-y-3">
<div>
<dt class="text-sm text-gray-500">Created</dt>
<dd class="text-sm text-gray-900">{new Date(collection.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Last Updated</dt>
<dd class="text-sm text-gray-900">{new Date(collection.updated_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Owner</dt>
<dd class="text-sm text-gray-900">{collection.owner_email}</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Collection ID</dt>
<dd class="text-xs text-gray-500 font-mono break-all">{collection.id}</dd>
</div>
</dl>
</div>
<!-- Danger Zone -->
{canManage && (
<div class="bg-white rounded-lg shadow p-6 border-2 border-red-200">
<h3 class="text-base font-medium text-red-900 mb-2">Danger Zone</h3>
<p class="text-sm text-gray-600 mb-4">These actions are permanent and cannot be undone.</p>
<button
id="delete-collection-btn"
class="w-full px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Delete Collection
</button>
</div>
)}
</div>
</div>
</div>
<!-- Edit Modal -->
<dialog id="edit-modal" class="rounded-lg shadow-xl p-0 max-w-2xl w-full backdrop:bg-gray-500 backdrop:bg-opacity-75">
<div class="bg-white rounded-lg">
<!-- Modal Header -->
<div class="flex items-start px-6 py-4 border-b border-gray-200">
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<div class="ml-3 flex-1">
<h3 class="text-lg font-semibold text-gray-900">Edit Collection</h3>
<p class="text-sm text-gray-500">Update your collection details</p>
</div>
</div>
<!-- Modal Body -->
<form id="edit-form" class="px-6 py-4 space-y-4">
<!-- Name -->
<div>
<label for="edit-name" class="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
id="edit-name"
name="name"
value={collection.name}
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<!-- Description -->
<div>
<label for="edit-description" class="block text-sm font-medium text-gray-700">Description</label>
<textarea
id="edit-description"
name="description"
rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>{collection.description}</textarea>
</div>
<!-- Environment -->
<div>
<label for="edit-environment" class="block text-sm font-medium text-gray-700">Environment</label>
<select
id="edit-environment"
name="environment"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="development" selected={collection.environment === 'development'}>Development</option>
<option value="testing" selected={collection.environment === 'testing'}>Testing</option>
<option value="staging" selected={collection.environment === 'staging'}>Staging</option>
<option value="production" selected={collection.environment === 'production'}>Production</option>
</select>
</div>
<!-- Status -->
<div>
<label for="edit-status" class="block text-sm font-medium text-gray-700">Status</label>
<select
id="edit-status"
name="status"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="draft" selected={collection.status === 'draft'}>Draft</option>
<option value="pending_approval" selected={collection.status === 'pending_approval'}>Pending Approval</option>
<option value="active" selected={collection.status === 'active'}>Active</option>
<option value="suspended" selected={collection.status === 'suspended'}>Suspended</option>
<option value="archived" selected={collection.status === 'archived'}>Archived</option>
</select>
<p class="mt-1 text-xs text-gray-500">Draft collections are editable. Active collections are deployed and running.</p>
</div>
<!-- Modal Actions -->
<div class="flex items-center justify-end space-x-3 pt-4">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Save Changes
</button>
<button
type="button"
id="cancel-edit-btn"
class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</button>
</div>
</form>
</div>
</dialog>
<!-- Resource Details Modal -->
<dialog id="resource-details-modal" class="rounded-lg shadow-xl p-0 max-w-3xl w-full backdrop:bg-gray-500 backdrop:bg-opacity-75">
<div class="bg-white rounded-lg max-h-[80vh] overflow-hidden flex flex-col">
<!-- Modal Header -->
<div class="flex items-start px-6 py-4 border-b border-gray-200 flex-shrink-0">
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
<div class="ml-3 flex-1">
<h3 class="text-lg font-semibold text-gray-900" id="resource-modal-title">Resource Details</h3>
<p class="text-sm text-gray-500" id="resource-modal-subtitle">Loading...</p>
</div>
<button
type="button"
id="close-resource-modal-btn"
class="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="px-6 py-4 overflow-y-auto flex-1" id="resource-details-content">
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="ml-3 text-gray-500">Loading resource details...</span>
</div>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200 bg-gray-50 flex-shrink-0">
<div class="text-xs text-gray-500" id="resource-last-sync">
Last synced: Unknown
</div>
<div class="flex items-center space-x-3">
<button
type="button"
id="refresh-resource-from-modal-btn"
class="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg class="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Refresh
</button>
<button
type="button"
id="close-resource-details-btn"
class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Close
</button>
</div>
</div>
</div>
</dialog>
<!-- DNS Record Create/Edit Modal -->
<dialog id="dns-record-modal" class="rounded-lg shadow-xl p-0 max-w-lg w-full backdrop:bg-gray-500 backdrop:bg-opacity-75">
<div class="bg-white rounded-lg">
<!-- Modal Header -->
<div class="flex items-center px-6 py-4 border-b border-gray-200">
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<h3 class="ml-3 text-lg font-semibold text-gray-900" id="dns-record-modal-title">Add DNS Record</h3>
<button
type="button"
id="close-dns-record-modal-btn"
class="ml-auto rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body - Form -->
<form id="dns-record-form" class="px-6 py-4 space-y-4">
<input type="hidden" id="dns-record-id" name="record_id" value="">
<!-- Record Type -->
<div>
<label for="dns-record-type" class="block text-sm font-medium text-gray-700">Record Type</label>
<select
id="dns-record-type"
name="record_type"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="A">A (IPv4 Address)</option>
<option value="AAAA">AAAA (IPv6 Address)</option>
<option value="CNAME">CNAME (Canonical Name)</option>
<option value="MX">MX (Mail Exchange)</option>
<option value="TXT">TXT (Text Record)</option>
<option value="NS">NS (Name Server)</option>
<option value="SRV">SRV (Service)</option>
<option value="CAA">CAA (Certificate Authority)</option>
</select>
</div>
<!-- Record Name -->
<div>
<label for="dns-record-name" class="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
id="dns-record-name"
name="name"
required
placeholder="@ for root, or subdomain"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<p class="mt-1 text-xs text-gray-500">Use @ for the root domain, or enter a subdomain name</p>
</div>
<!-- Record Data -->
<div>
<label for="dns-record-data" class="block text-sm font-medium text-gray-700">Value</label>
<input
type="text"
id="dns-record-data"
name="data"
required
placeholder="IP address, hostname, or value"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
</div>
<!-- TTL -->
<div>
<label for="dns-record-ttl" class="block text-sm font-medium text-gray-700">TTL (seconds)</label>
<select
id="dns-record-ttl"
name="ttl"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="120">2 minutes (120)</option>
<option value="300">5 minutes (300)</option>
<option value="600">10 minutes (600)</option>
<option value="900">15 minutes (900)</option>
<option value="1800">30 minutes (1800)</option>
<option value="3600">1 hour (3600)</option>
<option value="14400">4 hours (14400)</option>
<option value="86400">1 day (86400)</option>
</select>
</div>
<!-- Priority (for MX/SRV records) -->
<div id="dns-record-priority-field" class="hidden">
<label for="dns-record-priority" class="block text-sm font-medium text-gray-700">Priority</label>
<input
type="number"
id="dns-record-priority"
name="priority"
min="0"
max="65535"
placeholder="10"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<p class="mt-1 text-xs text-gray-500">Lower values have higher priority</p>
</div>
<!-- Error Message -->
<div id="dns-record-error" class="hidden rounded-md bg-red-50 p-3">
<div class="flex">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg>
<p class="ml-2 text-sm text-red-700" id="dns-record-error-message"></p>
</div>
</div>
</form>
<!-- Modal Footer -->
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200 bg-gray-50 space-x-3">
<button
type="button"
id="cancel-dns-record-btn"
class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Cancel
</button>
<button
type="submit"
form="dns-record-form"
id="save-dns-record-btn"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span id="save-dns-record-text">Save Record</span>
<svg id="save-dns-record-spinner" class="hidden ml-2 h-4 w-4 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
</div>
</dialog>
<!-- Toast Notification Container -->
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
</div>
</Layout>
<script define:vars={{ collectionId: collection.id, authToken, resources: JSON.stringify(resources) }}>
// Parse resources data
const resourcesData = JSON.parse(resources);
let currentResourceId = null;
// Get auth headers
function getAuthHeaders() {
return {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
};
}
// ==========================================
// Resource Actions - Dropdown Menu
// ==========================================
// Toggle resource action dropdown menu
window.toggleResourceMenu = function(resourceId) {
// Close all other menus first
document.querySelectorAll('.resource-dropdown').forEach(menu => {
if (menu.id !== `resource-menu-${resourceId}`) {
menu.classList.add('hidden');
}
});
const menu = document.getElementById(`resource-menu-${resourceId}`);
if (menu) {
menu.classList.toggle('hidden');
}
};
// Close menus when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.resource-actions-btn') && !e.target.closest('.resource-dropdown')) {
document.querySelectorAll('.resource-dropdown').forEach(menu => {
menu.classList.add('hidden');
});
}
});
// ==========================================
// View Resource Details
// ==========================================
const resourceDetailsModal = document.getElementById('resource-details-modal');
const resourceDetailsContent = document.getElementById('resource-details-content');
const resourceModalTitle = document.getElementById('resource-modal-title');
const resourceModalSubtitle = document.getElementById('resource-modal-subtitle');
const resourceLastSync = document.getElementById('resource-last-sync');
const closeResourceModalBtn = document.getElementById('close-resource-modal-btn');
const closeResourceDetailsBtn = document.getElementById('close-resource-details-btn');
const refreshResourceFromModalBtn = document.getElementById('refresh-resource-from-modal-btn');
window.viewResourceDetails = async function(resourceId) {
currentResourceId = resourceId;
// Close dropdown menu
document.querySelectorAll('.resource-dropdown').forEach(menu => {
menu.classList.add('hidden');
});
// Show modal with loading state
resourceDetailsModal?.showModal();
resourceModalTitle.textContent = 'Resource Details';
resourceModalSubtitle.textContent = 'Loading...';
resourceDetailsContent.innerHTML = `
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="ml-3 text-gray-500">Loading resource details...</span>
</div>
`;
try {
const response = await fetch(`/api/resources/managed/${resourceId}`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch resource details');
}
const resource = await response.json();
renderResourceDetails(resource);
} catch (error) {
console.error('Failed to load resource details:', error);
resourceDetailsContent.innerHTML = `
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Error loading resource</h3>
<p class="mt-1 text-sm text-gray-500">${error.message}</p>
</div>
`;
}
};
function renderResourceDetails(resource) {
const typeLabels = {
instance: 'Compute Instance',
load_balancer: 'Load Balancer',
database: 'Managed Database',
kubernetes: 'Kubernetes Cluster',
block_storage: 'Block Storage',
dns: 'DNS Domain',
object_storage: 'Object Storage',
vpc: 'VPC Network'
};
const statusColors = {
active: 'bg-green-100 text-green-800',
running: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
stopped: 'bg-gray-100 text-gray-800',
error: 'bg-red-100 text-red-800'
};
resourceModalTitle.textContent = resource.resource_name;
resourceModalSubtitle.textContent = typeLabels[resource.resource_type] || resource.resource_type;
// Format last sync time
if (resource.last_sync) {
const syncDate = new Date(resource.last_sync);
resourceLastSync.textContent = `Last synced: ${syncDate.toLocaleString()}`;
} else {
resourceLastSync.textContent = 'Never synced from Vultr';
}
// Build the details content
const metadata = resource.metadata || resource.resource_metadata || {};
const config = resource.configuration || {};
const vultrData = resource.cached_vultr_data || {};
let html = `
<div class="space-y-6">
<!-- Basic Info -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Basic Information</h4>
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Resource Name</dt>
<dd class="mt-1 text-sm text-gray-900">${resource.resource_name}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Type</dt>
<dd class="mt-1 text-sm text-gray-900">${typeLabels[resource.resource_type] || resource.resource_type}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Vultr ID</dt>
<dd class="mt-1 text-sm font-mono text-gray-900">${resource.vultr_resource_id}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Status</dt>
<dd class="mt-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${statusColors[resource.status] || 'bg-gray-100 text-gray-800'}">
${resource.status}
</span>
</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Monthly Cost</dt>
<dd class="mt-1 text-sm text-gray-900">${resource.monthly_cost ? '$' + resource.monthly_cost + '/mo' : '-'}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Import Source</dt>
<dd class="mt-1 text-sm text-gray-900">${resource.import_source || 'manual'}</dd>
</div>
</dl>
</div>
`;
// Region info if available
if (metadata.region) {
html += `
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Location</h4>
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Region</dt>
<dd class="mt-1 text-sm text-gray-900">${metadata.region}</dd>
</div>
</dl>
</div>
`;
}
// Configuration section
if (Object.keys(config).length > 0) {
html += `
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Configuration</h4>
<div class="bg-gray-50 rounded-md p-4">
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap">${JSON.stringify(config, null, 2)}</pre>
</div>
</div>
`;
}
// Cached Vultr Data section
if (Object.keys(vultrData).length > 0) {
html += `
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Vultr API Data</h4>
<div class="bg-gray-50 rounded-md p-4">
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap max-h-64">${JSON.stringify(vultrData, null, 2)}</pre>
</div>
</div>
`;
}
// Metadata section
if (Object.keys(metadata).length > 0) {
html += `
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Metadata</h4>
<div class="bg-gray-50 rounded-md p-4">
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap">${JSON.stringify(metadata, null, 2)}</pre>
</div>
</div>
`;
}
// Domain Settings section for domain resources (DNSSEC toggle)
if (resource.resource_type === 'dns' || resource.resource_type === 'domain') {
const dnssecStatus = vultrData.dns_sec || 'disabled';
const isDnssecEnabled = dnssecStatus === 'enabled';
html += `
<div id="domain-settings-section">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-gray-900">Domain Settings</h4>
</div>
<div class="bg-gray-50 rounded-md p-4 space-y-4">
<!-- DNSSEC Toggle -->
<div class="flex items-center justify-between">
<div>
<label for="dnssec-toggle" class="text-sm font-medium text-gray-700">DNSSEC</label>
<p class="text-xs text-gray-500">Enable DNSSEC to add cryptographic signatures to DNS records</p>
</div>
<div class="flex items-center space-x-3">
<span id="dnssec-status" class="text-sm ${isDnssecEnabled ? 'text-green-600' : 'text-gray-500'}">
${isDnssecEnabled ? 'Enabled' : 'Disabled'}
</span>
<button
type="button"
id="dnssec-toggle"
role="switch"
aria-checked="${isDnssecEnabled}"
data-resource-id="${resource.id}"
data-current-status="${dnssecStatus}"
onclick="toggleDnssec(this)"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${isDnssecEnabled ? 'bg-indigo-600' : 'bg-gray-200'}"
>
<span class="sr-only">Toggle DNSSEC</span>
<span
aria-hidden="true"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${isDnssecEnabled ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
</div>
${!resource.vultr_credential_id ? `
<div class="mt-2 text-xs text-amber-600 flex items-center">
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
Link a Vultr credential to enable DNSSEC changes
</div>
` : ''}
</div>
</div>
`;
}
// DNS Records section for domain resources
// Check for both 'dns' and 'domain' resource types
if (resource.resource_type === 'dns' || resource.resource_type === 'domain') {
html += `
<div id="dns-records-section">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-gray-900">DNS Records</h4>
<button
type="button"
id="load-dns-records-btn"
class="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md shadow-sm text-xs font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onclick="loadDnsRecords('${resource.id}')"
>
<svg class="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
</svg>
Load DNS Records
</button>
</div>
<div id="dns-records-container" class="bg-gray-50 rounded-md p-4">
<p class="text-sm text-gray-500 text-center">Click "Load DNS Records" to fetch records from Vultr</p>
</div>
</div>
`;
}
// Credential Linking Section
html += `
<div id="credential-section">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-gray-900">Vultr API Credential</h4>
</div>
${resource.vultr_credential_id ? `
<div class="bg-green-50 border border-green-200 rounded-md p-4">
<div class="flex items-center">
<svg class="h-5 w-5 text-green-500 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-green-800 font-medium">Credential linked</span>
</div>
<p class="mt-1 text-xs text-green-700">This resource can refresh data from Vultr API.</p>
</div>
` : `
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div class="flex items-start">
<svg class="h-5 w-5 text-yellow-500 mr-2 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<div class="flex-1">
<p class="text-sm text-yellow-800 font-medium">No credential linked</p>
<p class="mt-1 text-xs text-yellow-700">Link a Vultr credential to enable refresh and DNS record fetching.</p>
<div class="mt-3">
<label for="credential-select" class="block text-xs font-medium text-gray-700 mb-1">Select Credential</label>
<div class="flex items-center space-x-2">
<select
id="credential-select"
class="flex-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
>
<option value="">Loading credentials...</option>
</select>
<button
type="button"
id="link-credential-btn"
class="inline-flex items-center px-3 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled
onclick="linkCredentialToResource('${resource.id}')"
>
Link
</button>
</div>
<p class="mt-1 text-xs text-gray-500">
<a href="/credentials" class="text-indigo-600 hover:text-indigo-500">Manage credentials</a>
</p>
</div>
</div>
</div>
</div>
`}
</div>
`;
// Timestamps
html += `
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Timestamps</h4>
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Created</dt>
<dd class="mt-1 text-sm text-gray-900">${new Date(resource.created_at).toLocaleString()}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 uppercase">Last Sync</dt>
<dd class="mt-1 text-sm text-gray-900">${resource.last_sync ? new Date(resource.last_sync).toLocaleString() : 'Never'}</dd>
</div>
</dl>
</div>
`;
html += '</div>';
resourceDetailsContent.innerHTML = html;
// After rendering, load credentials if no credential is linked
if (!resource.vultr_credential_id) {
loadCredentialsForDropdown();
}
}
// ==========================================
// Load User Credentials for Dropdown
// ==========================================
async function loadCredentialsForDropdown() {
const selectEl = document.getElementById('credential-select');
const linkBtn = document.getElementById('link-credential-btn');
if (!selectEl) return;
try {
const response = await fetch('/api/vultr-credentials', {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch credentials');
}
const data = await response.json();
const credentials = data.items || [];
if (credentials.length === 0) {
selectEl.innerHTML = '<option value="">No credentials available</option>';
selectEl.disabled = true;
if (linkBtn) linkBtn.disabled = true;
} else {
selectEl.innerHTML = '<option value="">Select a credential...</option>';
credentials.forEach(cred => {
const option = document.createElement('option');
option.value = cred.id;
option.textContent = `${cred.label} (${cred.description || 'No description'})`;
selectEl.appendChild(option);
});
selectEl.disabled = false;
// Enable link button when credential is selected
selectEl.addEventListener('change', () => {
if (linkBtn) {
linkBtn.disabled = !selectEl.value;
}
});
}
} catch (error) {
console.error('Failed to load credentials:', error);
selectEl.innerHTML = '<option value="">Error loading credentials</option>';
selectEl.disabled = true;
if (linkBtn) linkBtn.disabled = true;
}
}
// ==========================================
// Link Credential to Resource
// ==========================================
window.linkCredentialToResource = async function(resourceId) {
const selectEl = document.getElementById('credential-select');
const linkBtn = document.getElementById('link-credential-btn');
if (!selectEl || !selectEl.value) {
alert('Please select a credential');
return;
}
const credentialId = selectEl.value;
// Disable button and show loading
if (linkBtn) {
linkBtn.disabled = true;
linkBtn.innerHTML = `
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
`;
}
try {
const response = await fetch(`/api/resources/managed/${resourceId}/link-credential`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ credential_id: credentialId })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to link credential');
}
const result = await response.json();
// Show success and re-render resource details
alert('Credential linked successfully! Resource data has been refreshed.');
// Re-fetch and render resource details
const detailsResponse = await fetch(`/api/resources/managed/${resourceId}`, {
headers: getAuthHeaders()
});
if (detailsResponse.ok) {
const resource = await detailsResponse.json();
renderResourceDetails(resource);
}
} catch (error) {
console.error('Failed to link credential:', error);
alert('Failed to link credential: ' + error.message);
// Reset button
if (linkBtn) {
linkBtn.disabled = false;
linkBtn.textContent = 'Link';
}
}
};
// ==========================================
// Toggle DNSSEC for Domain Resources
// ==========================================
window.toggleDnssec = async function(toggleButton) {
const resourceId = toggleButton.dataset.resourceId;
const currentStatus = toggleButton.dataset.currentStatus;
const newStatus = currentStatus === 'enabled' ? 'disabled' : 'enabled';
// Show loading state on the toggle
toggleButton.disabled = true;
toggleButton.classList.add('opacity-50', 'cursor-wait');
const statusSpan = document.getElementById('dnssec-status');
if (statusSpan) {
statusSpan.textContent = 'Updating...';
statusSpan.className = 'text-sm text-gray-400';
}
try {
const response = await fetch(`/api/resources/managed/${resourceId}/domain-settings`, {
method: 'PATCH',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ dns_sec: newStatus })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to update DNSSEC');
}
const data = await response.json();
// Update UI to reflect new state
const isEnabled = newStatus === 'enabled';
// Update toggle button appearance
toggleButton.setAttribute('aria-checked', isEnabled);
toggleButton.dataset.currentStatus = newStatus;
toggleButton.className = toggleButton.className.replace(
isEnabled ? 'bg-gray-200' : 'bg-indigo-600',
isEnabled ? 'bg-indigo-600' : 'bg-gray-200'
);
// Update the inner span (the toggle knob)
const knob = toggleButton.querySelector('span[aria-hidden="true"]');
if (knob) {
knob.className = knob.className.replace(
isEnabled ? 'translate-x-0' : 'translate-x-5',
isEnabled ? 'translate-x-5' : 'translate-x-0'
);
}
// Update status text
if (statusSpan) {
statusSpan.textContent = isEnabled ? 'Enabled' : 'Disabled';
statusSpan.className = `text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`;
}
// Show success notification
showNotification(`DNSSEC ${isEnabled ? 'enabled' : 'disabled'} successfully`, 'success');
} catch (error) {
console.error('Failed to toggle DNSSEC:', error);
// Revert status text on error
const isEnabled = currentStatus === 'enabled';
if (statusSpan) {
statusSpan.textContent = isEnabled ? 'Enabled' : 'Disabled';
statusSpan.className = `text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`;
}
showNotification(`Failed to update DNSSEC: ${error.message}`, 'error');
} finally {
// Re-enable toggle
toggleButton.disabled = false;
toggleButton.classList.remove('opacity-50', 'cursor-wait');
}
};
// ==========================================
// Load DNS Records for Domain Resources
// ==========================================
window.loadDnsRecords = async function(resourceId) {
const container = document.getElementById('dns-records-container');
const loadBtn = document.getElementById('load-dns-records-btn');
if (!container || !loadBtn) return;
// Show loading state
loadBtn.disabled = true;
loadBtn.innerHTML = `
<svg class="animate-spin h-4 w-4 mr-1.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
`;
container.innerHTML = `
<div class="flex items-center justify-center py-4">
<svg class="animate-spin h-5 w-5 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="ml-2 text-sm text-gray-500">Fetching DNS records from Vultr...</span>
</div>
`;
try {
const response = await fetch(`/api/resources/managed/${resourceId}/dns-records`, {
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to fetch DNS records');
}
const data = await response.json();
renderDnsRecords(data, container);
// Update button to "Refresh Records"
loadBtn.disabled = false;
loadBtn.innerHTML = `
<svg class="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Refresh Records
`;
} catch (error) {
console.error('Failed to load DNS records:', error);
container.innerHTML = `
<div class="text-center py-4">
<svg class="mx-auto h-8 w-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<p class="mt-2 text-sm text-red-600">${error.message}</p>
<p class="mt-1 text-xs text-gray-500">Make sure this resource has a linked Vultr credential.</p>
</div>
`;
// Reset button
loadBtn.disabled = false;
loadBtn.innerHTML = `
<svg class="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
</svg>
Retry
`;
}
};
// Store current DNS records data for editing
let currentDnsData = null;
function renderDnsRecords(data, container) {
const records = data.records || [];
currentDnsData = data; // Store for later use
// Record type badge colors
const typeColors = {
'A': 'bg-blue-100 text-blue-800',
'AAAA': 'bg-indigo-100 text-indigo-800',
'CNAME': 'bg-purple-100 text-purple-800',
'MX': 'bg-green-100 text-green-800',
'TXT': 'bg-yellow-100 text-yellow-800',
'NS': 'bg-gray-100 text-gray-800',
'SRV': 'bg-orange-100 text-orange-800',
'CAA': 'bg-pink-100 text-pink-800'
};
let html = `
<div class="mb-3 flex items-center justify-between">
<span class="text-xs text-gray-500">${records.length} record${records.length !== 1 ? 's' : ''} for <strong>${data.domain}</strong></span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">Fetched: ${new Date(data.fetched_at).toLocaleTimeString()}</span>
<button
type="button"
onclick="showCreateDnsRecordModal()"
class="inline-flex items-center px-2 py-1 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded shadow-sm transition-colors"
>
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Record
</button>
</div>
</div>
`;
if (records.length === 0) {
html += `<p class="text-sm text-gray-500 text-center py-4">No DNS records found for this domain.</p>`;
container.innerHTML = html;
return;
}
html += `
<div class="overflow-x-auto -mx-4 sm:mx-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-100">
<tr>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Data</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">TTL</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Priority</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
`;
for (const record of records) {
const typeColor = typeColors[record.type] || 'bg-gray-100 text-gray-800';
const displayName = record.name === '' ? '@' : record.name;
const displayPriority = record.priority >= 0 ? record.priority : '-';
// Truncate long data values
let displayData = record.data || '';
const isLongData = displayData.length > 50;
const truncatedData = isLongData ? displayData.substring(0, 47) + '...' : displayData;
// Escape data for use in onclick handlers
const escapedRecord = JSON.stringify(record).replace(/'/g, "\\'").replace(/"/g, '"');
html += `
<tr class="hover:bg-gray-50 group">
<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${typeColor}">
${record.type}
</span>
</td>
<td class="px-3 py-2 whitespace-nowrap text-sm font-mono text-gray-900">
${displayName}
</td>
<td class="px-3 py-2 text-sm font-mono text-gray-700 max-w-xs" title="${displayData.replace(/"/g, '"')}">
<span class="break-all">${truncatedData}</span>
</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
${record.ttl}s
</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
${displayPriority}
</td>
<td class="px-3 py-2 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onclick='editDnsRecord(${escapedRecord})'
class="p-1 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded transition-colors"
title="Edit record"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
<button
type="button"
onclick="deleteDnsRecord('${record.id}')"
class="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete record"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
</td>
</tr>
`;
}
html += `
</tbody>
</table>
</div>
`;
container.innerHTML = html;
}
// ==========================================
// DNS Record CRUD Operations
// ==========================================
window.showCreateDnsRecordModal = function() {
const modal = document.getElementById('dns-record-modal');
const form = document.getElementById('dns-record-form');
const title = document.getElementById('dns-record-modal-title');
if (!modal || !form) return;
// Reset form for creation
form.reset();
form.dataset.mode = 'create';
form.dataset.recordId = '';
title.textContent = 'Create DNS Record';
// Set defaults
document.getElementById('dns-record-ttl').value = '300';
// Show/hide priority based on type
updatePriorityVisibility();
modal.showModal();
};
window.editDnsRecord = function(record) {
const modal = document.getElementById('dns-record-modal');
const form = document.getElementById('dns-record-form');
const title = document.getElementById('dns-record-modal-title');
if (!modal || !form) return;
// Set form to edit mode
form.dataset.mode = 'edit';
form.dataset.recordId = record.id;
title.textContent = 'Edit DNS Record';
// Populate form fields
document.getElementById('dns-record-type').value = record.type;
document.getElementById('dns-record-name').value = record.name || '';
document.getElementById('dns-record-data').value = record.data || '';
document.getElementById('dns-record-ttl').value = record.ttl || 300;
const priorityField = document.getElementById('dns-record-priority');
if (record.priority >= 0) {
priorityField.value = record.priority;
} else {
priorityField.value = '';
}
// Show/hide priority based on type
updatePriorityVisibility();
modal.showModal();
};
window.deleteDnsRecord = async function(recordId) {
if (!currentResourceId || !recordId) return;
if (!confirm('Are you sure you want to delete this DNS record? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/resources/managed/${currentResourceId}/dns-records/${recordId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to delete DNS record');
}
// Refresh the DNS records list
await loadDnsRecords(currentResourceId);
} catch (error) {
console.error('Failed to delete DNS record:', error);
showNotification('Failed to delete DNS record: ' + error.message, 'error');
}
};
function updatePriorityVisibility() {
const typeSelect = document.getElementById('dns-record-type');
const priorityGroup = document.getElementById('dns-record-priority-field');
if (!typeSelect || !priorityGroup) return;
const type = typeSelect.value;
// MX and SRV records require priority
if (type === 'MX' || type === 'SRV') {
priorityGroup.classList.remove('hidden');
} else {
priorityGroup.classList.add('hidden');
}
}
// Handle DNS record form submission
const dnsRecordForm = document.getElementById('dns-record-form');
dnsRecordForm?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentResourceId) return;
const form = e.target;
const mode = form.dataset.mode;
const recordId = form.dataset.recordId;
const formData = new FormData(form);
const payload = {
record_type: formData.get('record_type'), // matches name="record_type" in form
name: formData.get('name'),
data: formData.get('data'),
ttl: parseInt(formData.get('ttl'), 10)
};
const priority = formData.get('priority');
if (priority !== '') {
payload.priority = parseInt(priority, 10);
}
const submitBtn = document.getElementById('save-dns-record-btn');
const saveTextEl = document.getElementById('save-dns-record-text');
const originalText = saveTextEl?.textContent || 'Save Record';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = `
<svg class="animate-spin h-4 w-4 mr-1.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Saving...
`;
}
try {
let url = `/api/resources/managed/${currentResourceId}/dns-records`;
let method = 'POST';
if (mode === 'edit') {
url += `/${recordId}`;
method = 'PATCH';
}
const response = await fetch(url, {
method,
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || `Failed to ${mode} DNS record`);
}
// Close modal and refresh records
document.getElementById('dns-record-modal')?.close();
await loadDnsRecords(currentResourceId);
showNotification(`DNS record ${mode === 'create' ? 'created' : 'updated'} successfully`, 'success');
} catch (error) {
console.error(`Failed to ${mode} DNS record:`, error);
showNotification(`Failed to ${mode} DNS record: ` + error.message, 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = `<span id="save-dns-record-text">${originalText}</span>`;
}
}
});
// Close DNS record modal handlers
document.getElementById('close-dns-record-modal-btn')?.addEventListener('click', () => {
document.getElementById('dns-record-modal')?.close();
});
document.getElementById('cancel-dns-record-btn')?.addEventListener('click', () => {
document.getElementById('dns-record-modal')?.close();
});
// Update priority visibility when type changes
document.getElementById('dns-record-type')?.addEventListener('change', updatePriorityVisibility);
// Simple notification helper
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg transition-all transform translate-x-0 ${
type === 'success' ? 'bg-green-500 text-white' :
type === 'error' ? 'bg-red-500 text-white' :
'bg-blue-500 text-white'
}`;
notification.innerHTML = `
<div class="flex items-center gap-2">
${type === 'success' ? '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>' : ''}
${type === 'error' ? '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /></svg>' : ''}
<span>${message}</span>
</div>
`;
document.body.appendChild(notification);
// Auto-remove after 4 seconds
setTimeout(() => {
notification.classList.add('translate-x-full', 'opacity-0');
setTimeout(() => notification.remove(), 300);
}, 4000);
}
// Close resource details modal
closeResourceModalBtn?.addEventListener('click', () => {
resourceDetailsModal?.close();
currentResourceId = null;
});
closeResourceDetailsBtn?.addEventListener('click', () => {
resourceDetailsModal?.close();
currentResourceId = null;
});
resourceDetailsModal?.addEventListener('click', (e) => {
if (e.target === resourceDetailsModal) {
resourceDetailsModal.close();
currentResourceId = null;
}
});
// ==========================================
// Refresh Resource from Vultr
// ==========================================
window.refreshResource = async function(resourceId) {
// Close dropdown menu
document.querySelectorAll('.resource-dropdown').forEach(menu => {
menu.classList.add('hidden');
});
// Show loading indicator
const btn = document.querySelector(`[data-resource-id="${resourceId}"]`);
if (btn) {
btn.disabled = true;
btn.innerHTML = '<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
}
try {
const response = await fetch(`/api/resources/managed/${resourceId}/refresh`, {
method: 'POST',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to refresh resource');
}
const result = await response.json();
// Show success message
alert('Resource refreshed successfully!');
// Reload page to show updated data
window.location.reload();
} catch (error) {
console.error('Failed to refresh resource:', error);
alert('Failed to refresh resource: ' + error.message);
// Restore button
if (btn) {
btn.disabled = false;
btn.innerHTML = '<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" /></svg>';
}
}
};
// Refresh from modal button
refreshResourceFromModalBtn?.addEventListener('click', async () => {
if (currentResourceId) {
refreshResourceFromModalBtn.disabled = true;
refreshResourceFromModalBtn.innerHTML = '<svg class="animate-spin h-4 w-4 mr-1.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Refreshing...';
try {
const response = await fetch(`/api/resources/managed/${currentResourceId}/refresh`, {
method: 'POST',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to refresh resource');
}
// Re-fetch and display updated details
const detailsResponse = await fetch(`/api/resources/managed/${currentResourceId}`, {
headers: getAuthHeaders()
});
if (detailsResponse.ok) {
const resource = await detailsResponse.json();
renderResourceDetails(resource);
}
// Show success
alert('Resource refreshed successfully!');
} catch (error) {
console.error('Failed to refresh resource:', error);
alert('Failed to refresh resource: ' + error.message);
} finally {
refreshResourceFromModalBtn.disabled = false;
refreshResourceFromModalBtn.innerHTML = '<svg class="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></svg> Refresh';
}
}
});
// ==========================================
// Remove Resource from Collection
// ==========================================
window.removeResource = async function(resourceId, resourceName) {
// Close dropdown menu
document.querySelectorAll('.resource-dropdown').forEach(menu => {
menu.classList.add('hidden');
});
if (!confirm(`Are you sure you want to remove "${resourceName}" from this collection?\n\nThis will NOT delete the resource from Vultr, only remove it from collection management.`)) {
return;
}
try {
const response = await fetch(`/api/resources/managed/${resourceId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to remove resource');
}
// Reload page to show updated list
window.location.reload();
} catch (error) {
console.error('Failed to remove resource:', error);
alert('Failed to remove resource: ' + error.message);
}
};
// ==========================================
// Edit modal functionality
// ==========================================
const editBtn = document.getElementById('edit-collection-btn');
const editModal = document.getElementById('edit-modal');
const cancelBtn = document.getElementById('cancel-edit-btn');
const editForm = document.getElementById('edit-form');
editBtn?.addEventListener('click', () => {
editModal?.showModal();
});
cancelBtn?.addEventListener('click', () => {
editModal?.close();
});
editModal?.addEventListener('click', (e) => {
if (e.target === editModal) {
editModal.close();
}
});
// Handle form submission
editForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const updateData = {
name: formData.get('name'),
description: formData.get('description') || null,
environment: formData.get('environment'),
status: formData.get('status')
};
try {
const response = await fetch(`/api/collections/${collectionId}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(updateData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to update collection');
}
// Reload page to show updates
window.location.reload();
} catch (error) {
console.error('Failed to update collection:', error);
alert('Failed to update collection: ' + error.message);
}
});
// Delete functionality
const deleteBtn = document.getElementById('delete-collection-btn');
deleteBtn?.addEventListener('click', async () => {
if (!confirm('Are you sure you want to delete this collection? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/collections/${collectionId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to delete collection');
}
// Redirect to collections page
window.location.href = '/collections';
} catch (error) {
console.error('Failed to delete collection:', error);
alert('Failed to delete collection: ' + error.message);
}
});
</script>